import md5 from "blueimp-md5";
import assert from "minimalistic-assert";
import * as z from "zod/mini";

import * as blueslip from "./blueslip.ts";
import * as channel from "./channel.ts";
import {FoldDict} from "./fold_dict.ts";
import {$t} from "./i18n.ts";
import * as internal_url from "./internal_url.ts";
import type {DisplayRecipientUser, Message, MessageWithBooleans} from "./message_store.ts";
import * as message_user_ids from "./message_user_ids.ts";
import * as muted_users from "./muted_users.ts";
import {page_params} from "./page_params.ts";
import * as reload_state from "./reload_state.ts";
import * as settings_config from "./settings_config.ts";
import * as settings_data from "./settings_data.ts";
import type {CurrentUser, StateData, profile_datum_schema} from "./state_data.ts";
import {current_user, realm, user_schema} from "./state_data.ts";
import * as timerender from "./timerender.ts";
import * as typeahead from "./typeahead.ts";
import {is_user_in_setting_group} from "./user_groups.ts";
import {user_settings} from "./user_settings.ts";
import * as util from "./util.ts";

export type ProfileDatum = z.infer<typeof profile_datum_schema>;
export type User = z.infer<typeof user_schema>;

export type SenderInfo = User & {
    avatar_url_small: string;
    is_muted: boolean;
};

// This type is generated by the `compose_typeahead.broadcast_mentions` function.
export type PseudoMentionUser = {
    special_item_text: string;
    email: string;
    secondary_text: string;
    pm_recipient_count: number;
    full_name: string;
    idx: number;
};

let people_dict: FoldDict<User>;
let people_by_name_dict: FoldDict<User>;
let people_by_user_id_dict: Map<number, User>;
let active_user_dict: Map<number, User>;
let non_active_user_dict: Map<number, User>;
let cross_realm_dict: Map<number, User>;
let pm_recipient_count_dict: Map<number, number>;
let duplicate_full_name_data: FoldDict<Set<number>>;
let my_user_id: number;
let valid_user_ids: Set<number>;
let fetch_users_storage: {
    pending_user_ids: Set<number>;
    in_transit_user_ids: Set<number>;
    // Will be resolved when fetch for `pending_user_ids` is complete.
    promise_for_pending: Promise<void> | undefined;
    promise_resolver_for_pending: (() => void) | undefined;
    // Contains sets of `pending_user_ids` that have been requested via
    // `start_fetch_for_requested_users`.
    promise_for_requested: Map<
        Set<number>,
        {
            promise: Promise<void>;
            resolver: () => void;
        }
    >;
    // Contains sets of user ids that are currently being fetched.
    promise_for_in_transit: Map<
        Set<number>,
        {
            promise: Promise<void>;
            resolver: () => void;
        }
    >;
};

export let INACCESSIBLE_USER_NAME: string;
export let WELCOME_BOT: User;
export let EMAIL_GATEWAY_BOT: User;

export const MAX_USER_NAME_LENGTH = 100;

// We have an init() function so that our automated tests
// can easily clear data.
export function init(): void {
    // The following three dicts point to the same objects
    // (all people we've seen), but people_dict can have duplicate
    // keys related to email changes.  We want to deprecate
    // people_dict over time and always do lookups by user_id.
    people_dict = new FoldDict();
    people_by_name_dict = new FoldDict();
    people_by_user_id_dict = new Map();

    // The next dictionary includes all active users (human/user)
    // in our realm, but it excludes non-active users and
    // cross-realm bots.
    active_user_dict = new Map();
    non_active_user_dict = new Map();
    cross_realm_dict = new Map(); // keyed by user_id
    pm_recipient_count_dict = new Map();
    valid_user_ids = new Set();

    // This maintains a set of ids of people with same full names.
    duplicate_full_name_data = new FoldDict();

    INACCESSIBLE_USER_NAME = $t({defaultMessage: "Unknown user"});

    fetch_users_storage = {
        pending_user_ids: new Set(),
        in_transit_user_ids: new Set(),
        promise_for_pending: undefined,
        promise_resolver_for_pending: undefined,
        promise_for_requested: new Map(),
        promise_for_in_transit: new Map(),
    };
}

// WE INITIALIZE DATA STRUCTURES HERE!
init();

export const user_fetch_response_schema = z.object({
    members: z.array(user_schema),
    result: z.string(),
    msg: z.string(),
});

type UsersFetchResponse = z.infer<typeof user_fetch_response_schema>;

type FetchUserDataParams = {
    user_ids: string;
    client_gravatar?: boolean;
    include_custom_profile_fields?: boolean;
    success?: (users: UsersFetchResponse["members"]) => void;
    error?: (xhr?: JQuery.jqXHR) => void;
};

export function add_valid_user_id(user_id: number): void {
    valid_user_ids.add(user_id);
}

export function is_valid_user_id(user_id: number): boolean {
    return valid_user_ids.has(user_id);
}

export function split_to_ints(lst: string): number[] {
    return lst.split(",").map((s) => Number.parseInt(s, 10));
}

export function get_users_from_ids(user_ids: number[]): User[] {
    return user_ids.map((user_id) => get_by_user_id(user_id));
}

// Use this function only when you are sure that user_id is valid.
export function get_by_user_id(user_id: number): User {
    const person = people_by_user_id_dict.get(user_id);
    assert(person, `Unknown user_id in get_by_user_id: ${user_id}`);
    return person;
}

// This is type unsafe version of get_by_user_id for the callers that expects undefined values.
export function maybe_get_user_by_id(user_id: number, ignore_missing = false): User | undefined {
    if (!people_by_user_id_dict.has(user_id) && !ignore_missing) {
        blueslip.error("Unknown user_id in maybe_get_user_by_id", {user_id});
        return undefined;
    }
    return people_by_user_id_dict.get(user_id);
}

export function validate_user_ids(user_ids: number[]): number[] {
    const good_ids = [];
    const bad_ids = [];

    for (const user_id of user_ids) {
        if (people_by_user_id_dict.has(user_id)) {
            good_ids.push(user_id);
        } else {
            bad_ids.push(user_id);
        }
    }

    if (bad_ids.length > 0) {
        blueslip.warn("We have untracked user_ids", {bad_ids});
    }

    return good_ids;
}

export function get_by_email(email: string): User | undefined {
    const person = people_dict.get(email);

    if (!person) {
        return undefined;
    }

    if (person.email.toLowerCase() !== email.toLowerCase()) {
        blueslip.warn(
            "Obsolete email passed to get_by_email: " + email + " new email = " + person.email,
        );
    }

    return person;
}

export function get_bot_owner_user(user: User & {is_bot: true}): User | undefined {
    const owner_id = user.bot_owner_id;

    if (owner_id === undefined || owner_id === null) {
        // This is probably a cross-realm bot.
        return undefined;
    }

    return get_user_by_id_assert_valid(owner_id);
}

export function can_admin_user(user: User): boolean {
    return (
        (user.is_bot && user.bot_owner_id !== null && user.bot_owner_id === current_user.user_id) ||
        is_my_user_id(user.user_id)
    );
}

export function id_matches_email_operand(user_id: number, email: string): boolean {
    const person = get_by_email(email);

    if (!person) {
        // The user may type bad data into the search bar, so
        // we don't complain too loud here.
        blueslip.debug("User email operand unknown: " + email);
        return false;
    }

    return person.user_id === user_id;
}

export function update_email(user_id: number, new_email: string): void {
    const person = get_by_user_id(user_id);
    person.email = new_email;
    people_dict.set(new_email, person);

    // For legacy reasons we don't delete the old email
    // keys in our dictionaries, so that reverse lookups
    // still work correctly.
}

export function sort_user_ids_by_username(user_ids: number[]): number[] {
    const name_id_dict = user_ids.map((user_id, index) => ({
        name: people_by_user_id_dict.has(user_id)
            ? people_by_user_id_dict.get(user_id)?.full_name
            : "?",
        user_id: user_ids[index]!,
    }));

    name_id_dict.sort((a, b) => util.strcmp(a.name!, b.name!));
    return name_id_dict.map(({user_id}) => user_id);
}

// A function that sorts Email according to the user's full name
export function sort_emails_by_username(emails: string[]): (string | undefined)[] {
    const user_ids = emails.map((email) => get_user_id(email));
    const name_email_dict = user_ids.map((user_id, index) => ({
        name:
            user_id !== undefined && people_by_user_id_dict.has(user_id)
                ? people_by_user_id_dict.get(user_id)?.full_name
                : "?",
        email: emails[index],
    }));
    name_email_dict.sort((a, b) => util.strcmp(a.name!, b.name!));
    return name_email_dict.map(({email}) => email);
}

export function get_visible_email(user: User): string {
    if (user.delivery_email) {
        return user.delivery_email;
    }
    return user.email;
}

export function get_user_id(email: string): number | undefined {
    const person = get_by_email(email);
    if (person === undefined) {
        blueslip.error("Unknown email for get_user_id", {email});
        return undefined;
    }
    const user_id = person.user_id;
    if (!user_id) {
        blueslip.error("No user_id found for email", {email});
        return undefined;
    }

    return user_id;
}

export function is_known_user_id(user_id: number): boolean {
    /*
    We may get a user_id from mention syntax that we don't
    know about if a user includes some random number in
    the mention syntax by manually typing it instead of
    selecting some user from typeahead.
    */

    /*
    This function also returns false for inaccessible users
    even though we have the user_id for them as we do not
    want to show the mention pill for them.
    */
    const person = maybe_get_user_by_id(user_id, true);
    if (person === undefined || person.is_inaccessible_user) {
        return false;
    }
    return true;
}

export function direct_message_group_string(message: Message): string | undefined {
    if (message.type !== "private") {
        return undefined;
    }

    assert(
        typeof message.display_recipient !== "string",
        "Private messages should have list of recipients",
    );
    let user_ids = message.display_recipient.map((recip) => recip.id);

    user_ids = user_ids.filter(
        (user_id) => user_id && people_by_user_id_dict.has(user_id) && !is_my_user_id(user_id),
    );

    if (user_ids.length <= 1) {
        return undefined;
    }

    user_ids = util.sorted_ids(user_ids);

    return user_ids.join(",");
}

export function user_ids_string_to_emails_string(user_ids_string: string): string | undefined {
    const user_ids = split_to_ints(user_ids_string);
    return user_ids_to_emails_string(user_ids);
}

export function user_ids_to_emails_string(user_ids: number[]): string | undefined {
    let emails = util.try_parse_as_truthy(
        user_ids.map((user_id) => {
            const person = people_by_user_id_dict.get(user_id);
            return person?.email;
        }),
    );

    if (emails === undefined) {
        blueslip.warn("Unknown user ids: " + user_ids.join(","));
        return undefined;
    }

    emails = emails.map((email) => email.toLowerCase());

    return sort_emails_by_username(emails).join(",");
}

export function user_ids_string_to_ids_array(user_ids_string: string): number[] {
    const user_ids = user_ids_string.length === 0 ? [] : user_ids_string.split(",");
    const ids = user_ids.map((user_id_string) => {
        const user_id = Number(user_id_string);
        assert(!Number.isNaN(user_id));
        return user_id;
    });
    return ids;
}

export function get_participants_from_user_ids_string(user_ids_string: string): Set<number> {
    // Convert to set to ensure there are no duplicate ids.
    const user_ids = new Set(user_ids_string_to_ids_array(user_ids_string));
    // For group or 1:1 direct messages, the user_ids_string contains
    // just the other user, so we need to add ourselves if not already
    // present. For a direct message to oneself, the current user is
    // already present, in user_ids_string, so we don't need to add it
    // which is take care of by user_ids being a `Set`.
    user_ids.add(my_user_id);
    return user_ids;
}

export function emails_strings_to_user_ids_array(emails_string: string): number[] | undefined {
    const user_ids_string = emails_strings_to_user_ids_string(emails_string);
    if (user_ids_string === undefined) {
        return undefined;
    }

    const user_ids_array = user_ids_string_to_ids_array(user_ids_string);
    return user_ids_array;
}

export function reply_to_to_user_ids_string(emails_string: string): string | undefined {
    // This is basically emails_strings_to_user_ids_string
    // without blueslip warnings, since it can be called with
    // invalid data.
    const emails = emails_string.split(",");

    let user_ids = util.try_parse_as_truthy(
        emails.map((email) => {
            const person = get_by_email(email);
            return person?.user_id;
        }),
    );

    if (user_ids === undefined) {
        return undefined;
    }

    user_ids = util.sorted_ids(user_ids);

    return user_ids.join(",");
}

export function user_ids_to_full_names_string(user_ids: number[]): string {
    const sorted_names = user_ids.map((user_id) => {
        const person = maybe_get_user_by_id(user_id);
        if (person !== undefined) {
            return person.full_name;
        }
        return INACCESSIBLE_USER_NAME;
    });
    sorted_names.sort(util.make_strcmp());
    return sorted_names.join(", ");
}

export function get_user_time(user_id: number): string | undefined {
    const user_timezone = get_by_user_id(user_id).timezone;
    if (user_timezone) {
        try {
            return new Date().toLocaleTimeString(user_settings.default_language, {
                ...timerender.get_format_options_for_type(
                    "time",
                    user_settings.twenty_four_hour_time,
                ),
                timeZone: user_timezone,
            });
        } catch (error) {
            blueslip.warn(`Error formatting time in ${user_timezone}: ${String(error)}`);
        }
    }
    return undefined;
}

export function get_user_type(user_id: number): string | undefined {
    const user_profile = get_by_user_id(user_id);
    return settings_config.user_role_map.get(user_profile.role);
}

export function emails_strings_to_user_ids_string(emails_string: string): string | undefined {
    const emails = emails_string.split(",").map((email) => email.trim());
    return email_list_to_user_ids_string(emails);
}

export function emails_string_to_user_ids(emails_string: string): number[] {
    const user_ids_string = email_list_to_user_ids_string(
        util.extract_pm_recipients(emails_string),
    );
    return user_ids_string ? user_ids_string_to_ids_array(user_ids_string) : [];
}

export function email_list_to_user_ids_string(emails: string[]): string | undefined {
    let user_ids = util.try_parse_as_truthy(
        emails.map((email) => {
            const person = get_by_email(email);
            return person?.user_id;
        }),
    );

    if (user_ids === undefined) {
        blueslip.warn("Unknown emails", {emails});
        return undefined;
    }

    user_ids = util.sorted_ids(user_ids);

    return user_ids.join(",");
}

export function get_full_names_for_poll_option(user_ids: number[]): string {
    return get_display_full_names(user_ids).join(", ");
}

export function get_display_full_name(user_id: number): string {
    const person = get_user_by_id_assert_valid(user_id);

    if (muted_users.is_user_muted(user_id)) {
        if (should_add_guest_user_indicator(user_id)) {
            return $t({defaultMessage: "Muted user (guest)"});
        }

        return $t({defaultMessage: "Muted user"});
    }

    if (should_add_guest_user_indicator(user_id)) {
        return $t({defaultMessage: "{name} (guest)"}, {name: person.full_name});
    }

    return person.full_name;
}

export function get_display_full_names(user_ids: number[]): string[] {
    return user_ids.map((user_id) => get_display_full_name(user_id));
}

export function get_full_name(user_id: number): string {
    const person = get_by_user_id(user_id);
    return person.full_name;
}

function _calc_user_and_other_ids(user_ids_string: string): {
    user_ids: number[];
    other_ids: number[];
} {
    const user_ids = split_to_ints(user_ids_string);
    const other_ids = user_ids.filter((user_id) => !is_my_user_id(user_id));
    return {user_ids, other_ids};
}

export function get_recipients(user_ids_string: string): string[] {
    // See message_store.get_pm_full_names() for a similar function.

    const {other_ids} = _calc_user_and_other_ids(user_ids_string);

    if (other_ids.length === 0) {
        // direct message with oneself
        return [my_full_name()];
    }

    const sorted_names = get_display_full_names(other_ids);
    sorted_names.sort(util.make_strcmp());

    return sorted_names;
}

export function format_recipients(
    users_ids_string: string,
    join_strategy: "long" | "narrow",
): string {
    const formatted_recipients_string = util.format_array_as_list_with_conjunction(
        get_recipients(users_ids_string),
        join_strategy,
    );
    return formatted_recipients_string;
}

export function pm_reply_user_string(message: Message | MessageWithBooleans): string | undefined {
    const user_ids = pm_with_user_ids(message);

    if (!user_ids) {
        return undefined;
    }

    return user_ids.join(",");
}

export function pm_reply_to(message: Message): string | undefined {
    const user_ids = pm_with_user_ids(message);

    if (!user_ids) {
        return undefined;
    }

    const emails = user_ids.map((user_id) => {
        const person = people_by_user_id_dict.get(user_id);
        if (!person) {
            blueslip.error("Unknown user id in message", {user_id});
            return "?";
        }
        return person.email;
    });

    const reply_to = sort_emails_by_username(emails).join(",");

    return reply_to;
}

export function sorted_other_user_ids(user_ids: number[]): number[] {
    // This excludes your own user id unless you're the only user
    // (i.e. you sent a message to yourself).

    const other_user_ids = user_ids.filter((user_id) => !is_my_user_id(user_id));

    if (other_user_ids.length > 0) {
        user_ids = other_user_ids;
    } else {
        user_ids = [my_user_id];
    }

    user_ids = util.sorted_ids(user_ids);

    return user_ids;
}

export function concat_direct_message_group(user_ids: number[], user_id: number): string {
    /*
        We assume user_ids and user_id have already
        been validated by the caller.

        The only logic we're encapsulating here is
        how to encode direct message group.
    */
    const sorted_ids = util.sorted_ids([...user_ids, user_id]);
    return sorted_ids.join(",");
}

export function pm_lookup_key_from_user_ids(user_ids: number[]): string {
    /*
        The server will sometimes include our own user id
        in keys for direct messages, but we only want our
        user id if we sent a direct message to ourself.
    */
    user_ids = sorted_other_user_ids(user_ids);
    return user_ids.join(",");
}

export function pm_lookup_key(user_ids_string: string): string {
    const user_ids = split_to_ints(user_ids_string);
    return pm_lookup_key_from_user_ids(user_ids);
}

export function all_user_ids_in_pm(message: Message): number[] | undefined {
    if (message.type !== "private") {
        return undefined;
    }

    assert(
        typeof message.display_recipient !== "string",
        "Private messages should have list of recipients",
    );

    if (message.display_recipient.length === 0) {
        blueslip.error("Empty recipient list in message");
        return undefined;
    }

    let user_ids = message.display_recipient.map((recip) => recip.id);

    user_ids = util.sorted_ids(user_ids);
    return user_ids;
}

export function pm_with_user_ids(message: Message | MessageWithBooleans): number[] | undefined {
    if (message.type !== "private") {
        return undefined;
    }

    assert(
        typeof message.display_recipient !== "string",
        "Private messages should have list of recipients",
    );
    // Ideally display_recipient would be not optional in LocalMessage
    // or MessageWithBooleans, ideally by refactoring the use of
    // `build_display_recipient`, but that's complicated to type right now.
    // When we have a new format for `display_recipient` in message objects in
    // the API itself, we'll naturally clean this up.
    assert(message.display_recipient !== undefined);

    if (message.display_recipient.length === 0) {
        blueslip.error("Empty recipient list in message");
        return undefined;
    }

    const user_ids = message.display_recipient.map((recip) => recip.id);

    return sorted_other_user_ids(user_ids);
}

export function pm_perma_link(message: Message): string | undefined {
    const user_ids = all_user_ids_in_pm(message);

    if (!user_ids) {
        return undefined;
    }

    let suffix;

    if (user_ids.length >= 3) {
        suffix = "group";
    } else {
        suffix = "dm";
    }

    const slug = user_ids.join(",") + "-" + suffix;
    const url = "#narrow/dm/" + slug;
    return url;
}

export function get_slug_from_full_name(full_name: string): string {
    // We don't use \p{C} or \p{Cs} due to https://bugs.webkit.org/show_bug.cgi?id=267011
    return internal_url.encodeHashComponent(
        full_name.replaceAll(/[ "%/<>`\p{Cc}\p{Cf}\p{Co}\p{Cn}]+/gu, "-"),
    );
}

export function pm_with_url(message: Message | MessageWithBooleans): string | undefined {
    const user_ids = pm_with_user_ids(message);

    if (user_ids?.[0] === undefined) {
        return undefined;
    }

    let suffix;

    if (user_ids.length > 1) {
        suffix = "group";
    } else {
        const person = maybe_get_user_by_id(user_ids[0]);
        if (person?.full_name) {
            suffix = get_slug_from_full_name(person.full_name);
        } else {
            blueslip.error("Unknown people in message");
            suffix = "unk";
        }
    }

    const slug = user_ids.join(",") + "-" + suffix;
    const url = "#narrow/dm/" + slug;
    return url;
}

export function update_email_in_reply_to(
    reply_to: string,
    user_id: number,
    new_email: string,
): string {
    // We try to replace an old email with a new email in a reply_to,
    // but we try to avoid changing the reply_to if we don't have to,
    // and we don't warn on any errors.
    let emails = reply_to.split(",");

    const persons = util.try_parse_as_truthy(emails.map((email) => people_dict.get(email.trim())));

    if (persons === undefined) {
        return reply_to;
    }

    const needs_patch = persons.some((person) => person.user_id === user_id);

    if (!needs_patch) {
        return reply_to;
    }

    emails = persons.map((person) => {
        if (person.user_id === user_id) {
            return new_email;
        }
        return person.email;
    });

    return emails.join(",");
}

export function pm_with_operand_ids(operand: string): number[] | undefined {
    let emails = operand.split(",");
    emails = emails.map((email) => email.trim());
    let persons = util.try_parse_as_truthy(emails.map((email) => people_dict.get(email)));

    if (persons === undefined) {
        return undefined;
    }

    // If your email is included in a group direct message with other people,
    // then ignore it.
    if (persons.length > 1) {
        const my_user = people_by_user_id_dict.get(my_user_id);
        persons = persons.filter((person) => person !== my_user);
    }

    let user_ids = persons.map((person) => person.user_id);

    user_ids = util.sorted_ids(user_ids);

    return user_ids;
}

export function filter_other_guest_ids(user_ids: number[]): number[] {
    return util.sorted_ids(
        user_ids.filter((id) => id !== current_user.user_id && get_by_user_id(id)?.is_guest),
    );
}

export function user_ids_to_full_names_array(user_ids: number[]): string[] {
    const names = user_ids.map((user_id) => get_by_user_id(user_id).full_name);
    names.sort(util.strcmp);
    return names;
}

export function emails_to_slug(emails_string: string): string | undefined {
    let slug = reply_to_to_user_ids_string(emails_string);

    if (!slug) {
        return undefined;
    }

    slug += "-";

    const emails = emails_string.split(",");

    if (emails.length === 1 && emails[0] !== undefined) {
        const person = get_by_email(emails[0]);
        assert(person !== undefined, "Unknown person in emails_to_slug");
        slug += get_slug_from_full_name(person.full_name);
    } else {
        slug += "group";
    }

    return slug;
}

export function user_ids_to_slug(user_ids: number[]): string | undefined {
    if (user_ids.length === 0) {
        return undefined;
    }

    let slug = String(user_ids);
    slug += "-";
    if (user_ids.length === 1 && user_ids[0] !== undefined) {
        const person = get_by_user_id(user_ids[0]);
        assert(person !== undefined, "Unknown person in user_ids_string_to_slug");
        slug += get_slug_from_full_name(person.full_name);
    } else {
        slug += "group";
    }
    return slug;
}

export function user_ids_string_to_slug(user_ids_string: string): string | undefined {
    const user_ids = user_ids_string_to_ids_array(user_ids_string);
    return user_ids_to_slug(user_ids);
}

export function slug_to_emails(slug: string): string | undefined {
    /*
        It's not super important to be flexible about
        direct message related slugs, since you would
        rarely post them to the web, but we do want
        to support reasonable variations:

            99-alice@example.com
            99

        Our canonical version is 99-alice@example.com,
        and we only care about the "99" prefix.
    */
    const m = /^([\d,]+)(-.*)?/.exec(slug);
    if (m) {
        let user_ids_string = m[1]!;
        user_ids_string = exclude_me_from_string(user_ids_string);
        return user_ids_string_to_emails_string(user_ids_string);
    }
    /* istanbul ignore next */
    return undefined;
}

export function exclude_me_from_string(user_ids_string: string): string {
    // Exclude me from a user_ids_string UNLESS I'm the
    // only one in it.
    let user_ids = split_to_ints(user_ids_string);

    if (user_ids.length <= 1) {
        // We either have a message to ourself, an empty
        // slug, or a message to somebody else where we weren't
        // part of the slug.
        return user_ids.join(",");
    }

    user_ids = user_ids.filter((user_id) => !is_my_user_id(user_id));

    return user_ids.join(",");
}

export function sender_is_bot(message: Message): boolean {
    if (message.sender_id) {
        const person = get_by_user_id(message.sender_id);
        return person.is_bot;
    }
    return false;
}

export function sender_is_guest(message: Message): boolean {
    if (message.sender_id) {
        const person = get_by_user_id(message.sender_id);
        return person.is_guest;
    }
    return false;
}

export function sender_is_deactivated(message: Message): boolean {
    const sender_id = message.sender_id;
    if (sender_id) {
        return !is_active_user_for_popover(message.sender_id);
    }
    return false;
}

export function is_valid_bot_user(user_id: number): boolean {
    const user = maybe_get_user_by_id(user_id, true);
    return user?.is_bot ?? false;
}

export function should_add_guest_user_indicator(user_id: number): boolean {
    if (!realm.realm_enable_guest_user_indicator) {
        return false;
    }

    const user = get_by_user_id(user_id);
    return user.is_guest;
}

export function user_can_initiate_direct_message_thread(recipient_ids_string: string): boolean {
    const recipient_ids = user_ids_string_to_ids_array(recipient_ids_string);
    if (is_user_in_setting_group(realm.realm_direct_message_initiator_group, my_user_id)) {
        return true;
    }
    for (const recipient of recipient_ids) {
        if (!is_valid_bot_user(recipient) && recipient !== my_user_id) {
            return false;
        }
    }
    return true;
}

export function user_can_direct_message(recipient_ids_string: string): boolean {
    const recipient_ids = user_ids_string_to_ids_array(recipient_ids_string);
    if (is_user_in_setting_group(realm.realm_direct_message_permission_group, my_user_id)) {
        return true;
    }

    let other_human_recipients_exist = false;
    for (const recipient_id of recipient_ids) {
        if (is_valid_bot_user(recipient_id) || recipient_id === my_user_id) {
            continue;
        }
        if (is_user_in_setting_group(realm.realm_direct_message_permission_group, recipient_id)) {
            return true;
        }
        other_human_recipients_exist = true;
    }
    return !other_human_recipients_exist;
}

export function gravatar_url_for_email(email: string): string {
    const hash = md5(email.toLowerCase());
    return "https://secure.gravatar.com/avatar/" + hash + "?d=identicon";
}

export function small_avatar_url_for_person(person: User | CurrentUser): string {
    if (person.avatar_url) {
        return person.avatar_url;
    }

    if (person.avatar_url === null) {
        person.avatar_url = gravatar_url_for_email(person.email);
        return person.avatar_url;
    }

    return `/avatar/${person.user_id}`;
}

export function medium_avatar_url_for_person(person: User): string {
    /* Unlike the small avatar URL case, we don't generally have a
     * medium avatar URL included in person objects. So only have the
     * gravatar and server endpoints here. */

    if (person.avatar_url === null) {
        person.avatar_url = gravatar_url_for_email(person.email);
    }

    if (person.avatar_url !== undefined) {
        const url = new URL(person.avatar_url, window.location.origin);
        if (url.origin === "https://secure.gravatar.com") {
            url.search += (url.search ? "&" : "") + "s=500";
            return url.href;
        }
    }

    // We need to attach a version to the URL as a cache-breaker so that the browser
    // will update the image in real time when user uploads a new avatar.
    //
    // TODO: Newly created users sometimes are first learned about via
    // the report_late_add code path; these are missing the avatar_version
    // metadata. Long term, we should clean up that possibility, but
    // until it is, we fallback to using a version number of 0.
    return `/avatar/${person.user_id}/medium?version=${person.avatar_version ?? 0}`;
}

export function sender_info_for_recent_view_row(sender_ids: number[]): SenderInfo[] {
    const senders_info = [];
    for (const id of sender_ids) {
        // TODO: Better handling for optional values w/o the assertion.
        const person = get_by_user_id(id);
        const sender: SenderInfo = {
            ...person,
            avatar_url_small: small_avatar_url_for_person(person),
            is_muted: muted_users.is_user_muted(id),
        };
        senders_info.push(sender);
    }
    return senders_info;
}

export function small_avatar_url(message: Message): string {
    // Try to call this function in all places where we need 25px
    // avatar images, so that the browser can help
    // us avoid unnecessary network trips.  (For user-uploaded avatars,
    // the s=25 parameter is essentially ignored, but it's harmless.)
    //
    // We actually request these at s=50, so that we look better
    // on retina displays.

    let person;
    if (message.sender_id) {
        // We should always have message.sender_id, except for in the
        // tutorial, where it's ok to fall back to the URL in the fake
        // messages.
        person = maybe_get_user_by_id(message.sender_id);
    }

    // The first time we encounter a sender in a message, we may
    // not have person.avatar_url set, but if we do, then use that.
    if (person?.avatar_url) {
        return small_avatar_url_for_person(person);
    }

    // Try to get info from the message if we didn't have a `person` object
    // or if the avatar was missing. We do this verbosely to avoid false
    // positives on line coverage (we don't do branch checking).
    if (message.avatar_url) {
        return message.avatar_url;
    }

    if (person && person.avatar_url === undefined) {
        // If we don't have an avatar_url at all, we use `GET
        // /avatar/{user_id}` endpoint to obtain avatar url.  This is
        // required to take advantage of the user_avatar_url_field_optional
        // optimization, which saves a huge amount of network traffic on
        // servers with 10,000s of user accounts.
        return `/avatar/${person.user_id}`;
    }

    // For computing the user's email, we first trust the person
    // object since that is updated via our real-time sync system, but
    // if unavailable, we use the sender email.
    let email;
    if (person) {
        email = person.email;
    } else {
        email = message.sender_email;
    }

    return gravatar_url_for_email(email);
}

export function get_muted_user_avatar_url(): string {
    return "/static/images/muted-user/muted-sender.png";
}

export function is_valid_user_id_for_compose(user_id: number): boolean {
    if (cross_realm_dict.has(user_id)) {
        return true;
    }

    const person = maybe_get_user_by_id(user_id);
    if (!person || person.is_inaccessible_user) {
        return false;
    }

    // we allow deactivated users in compose so that
    // one can attempt to reply to threads that contained them.
    return true;
}

export function is_valid_email_for_compose(email: string): boolean {
    if (is_cross_realm_email(email)) {
        return true;
    }

    const person = get_by_email(email);
    if (!person || person.is_inaccessible_user) {
        return false;
    }

    // we allow deactivated users in compose so that
    // one can attempt to reply to threads that contained them.
    return true;
}

export function is_valid_bulk_emails_for_compose(emails: string[]): boolean {
    // Returns false if at least one of the emails is invalid.
    return emails.every((email) => {
        if (!is_valid_email_for_compose(email)) {
            return false;
        }
        return true;
    });
}

export function is_valid_bulk_user_ids_for_compose(user_ids: number[]): boolean {
    // Returns false if at least one of the user_ids is invalid.
    return user_ids.every((user_id) => {
        if (!is_valid_user_id_for_compose(user_id)) {
            return false;
        }
        return true;
    });
}

export function is_active_user_for_popover(user_id: number): boolean {
    // For popover menus, we include cross-realm bots as active
    // users.

    if (cross_realm_dict.get(user_id)) {
        return true;
    }
    if (active_user_dict.has(user_id)) {
        return true;
    }

    // TODO: We can report errors here once we start loading
    //       deactivated users at page-load time. For now just warn.
    if (!people_by_user_id_dict.has(user_id)) {
        blueslip.warn("Unexpectedly invalid user_id in user popover query", {user_id});
        // We return true for inaccessible users. We can assume
        // this code will not be called for invalid IDs.
        return true;
    }

    const user = people_by_user_id_dict.get(user_id)!;
    if (user.is_inaccessible_user) {
        return true;
    }

    return false;
}

export function is_current_user_only_owner(): boolean {
    if (!current_user.is_owner) {
        return false;
    }

    for (const person of active_user_dict.values()) {
        if (person.is_owner && !person.is_bot && person.user_id !== my_user_id) {
            return false;
        }
    }
    return true;
}

export function filter_all_persons(pred: (person: User) => boolean): User[] {
    const ret = [];
    for (const person of people_by_user_id_dict.values()) {
        if (person.is_inaccessible_user) {
            continue;
        }

        if (pred(person)) {
            ret.push(person);
        }
    }
    return ret;
}

export function filter_all_users(pred: (person: User) => boolean): User[] {
    const ret = [];
    for (const person of active_user_dict.values()) {
        if (pred(person)) {
            ret.push(person);
        }
    }
    return ret;
}

export function get_realm_users(): User[] {
    // includes humans and bots from your realm
    return [...active_user_dict.values()];
}

export function get_realm_users_and_welcome_bot(): User[] {
    return [...active_user_dict.values(), WELCOME_BOT];
}

export function get_realm_users_and_system_bots(): User[] {
    return [...active_user_dict.values(), ...cross_realm_dict.values()];
}

export function get_realm_active_human_users(): User[] {
    // includes ONLY humans from your realm
    const humans = [];

    for (const user of active_user_dict.values()) {
        if (!user.is_bot) {
            humans.push(user);
        }
    }

    return humans;
}

export function get_realm_active_human_user_ids(): number[] {
    const human_ids = [];

    for (const user of active_user_dict.values()) {
        if (!user.is_bot) {
            human_ids.push(user.user_id);
        }
    }

    return human_ids;
}

export function get_non_active_human_ids(): number[] {
    const human_ids = [];

    for (const user of non_active_user_dict.values()) {
        if (!user.is_bot) {
            human_ids.push(user.user_id);
        }
    }

    return human_ids;
}

export function get_non_active_user_ids_count(user_ids: number[]): number {
    let count = 0;
    for (const user_id of user_ids) {
        if (non_active_user_dict.has(user_id)) {
            count += 1;
        }
    }
    return count;
}

export function get_bot_ids(): number[] {
    const bot_ids = [];

    for (const user of people_by_user_id_dict.values()) {
        if (user.is_bot) {
            bot_ids.push(user.user_id);
        }
    }

    return bot_ids;
}

export let get_active_human_count = (): number => {
    let count = 0;
    for (const person of active_user_dict.values()) {
        if (!person.is_bot) {
            count += 1;
        }
    }
    return count;
};

export function rewire_get_active_human_count(value: typeof get_active_human_count): void {
    get_active_human_count = value;
}

export function get_active_user_ids(): number[] {
    // This includes active users and active bots.
    return [...active_user_dict.keys()];
}

export function get_non_active_realm_users(): User[] {
    return [...non_active_user_dict.values()];
}

export function is_cross_realm_email(email: string): boolean {
    const person = get_by_email(email);
    if (!person) {
        return false;
    }
    return cross_realm_dict.has(person.user_id);
}

export function get_recipient_count(person: User | PseudoMentionUser): number {
    // We can have fake person objects like the "all"
    // pseudo-person in at-mentions.  They will have
    // the pm_recipient_count on the object itself.
    if ("pm_recipient_count" in person) {
        return person.pm_recipient_count;
    }

    /*
        For searching in the search bar, we will
        have true `person` objects with `user_id`.

        Likewise, we'll have user_id if we are
        tab-completing a user to send a direct message
        to (but we only get called if we're not
        currently in a stream view).

        Finally, we'll have user_id if we are adding
        people to a stream (w/typeahead).

    */
    const count = pm_recipient_count_dict.get(person.user_id);

    return count ?? 0;
}

export function incr_recipient_count(user_id: number): void {
    const old_count = pm_recipient_count_dict.get(user_id) ?? 0;
    pm_recipient_count_dict.set(user_id, old_count + 1);
}

export function clear_recipient_counts_for_testing(): void {
    pm_recipient_count_dict.clear();
}

export function set_recipient_count_for_testing(user_id: number, count: number): void {
    pm_recipient_count_dict.set(user_id, count);
}

export function get_message_people(): User[] {
    /*
        message_people are roughly the people who have
        actually sent messages that are currently
        showing up on your feed. These people
        are important--we give them preference
        over other users in certain search
        suggestions, since non-message-people are
        presumably either not very active or
        possibly subscribed to streams you don't
        care about.

        message_people also includes people whom
        you have sent direct messages, but look at
        the message_store code to see the precise
        semantics
    */
    const message_people = util.try_parse_as_truthy(
        message_user_ids
            .user_ids()
            .map((user_id) => people_by_user_id_dict.get(user_id))
            .filter(Boolean),
    );

    return message_people ?? [];
}

export function get_active_message_people(): User[] {
    const message_people = get_message_people();
    const active_message_people = message_people.filter((item) =>
        active_user_dict.has(item.user_id),
    );
    return active_message_people;
}

export function get_people_for_search_bar(query: string): User[] {
    const pred = build_person_matcher(query);

    const message_people = get_message_people().filter((user) => !user.is_inaccessible_user);

    const small_results = message_people.filter((item) => pred(item));

    if (small_results.length >= 5) {
        return small_results;
    }

    return filter_all_persons(pred);
}

export function should_remove_diacritics_for_query(query_lower_case: string): boolean {
    // We only do diacritic-sensitive matching for queries that do not
    // contain diacritics themselves.
    //
    // TODO: This check is too strict; ideally we'd check for presence
    // of diacritics; punctuation should not be relevant.
    return /^[a-z]+$/.test(query_lower_case);
}

export function maybe_remove_diacritics_from_name(
    user: User,
    should_remove_diacritics: boolean,
): string {
    // Callers should compute should_remove_diacritics using
    // should_remove_diacritics_for_query. It's fastest if the caller
    // computes that once outside the loop over all users.
    if (should_remove_diacritics) {
        // Reuse removed diacritics version of the `full_name` if
        // present, since it's expensive to compute.
        user.name_with_diacritics_removed ??= typeahead.remove_diacritics(user.full_name);
        return user.name_with_diacritics_removed;
    }
    return user.full_name;
}

export function build_termlet_matcher(termlet: string): (user: User) => boolean {
    // Note: termlets are required to be lower case.
    termlet = termlet.trim();
    const should_remove_diacritics = should_remove_diacritics_for_query(termlet);

    return function (user: User): boolean {
        const full_name = maybe_remove_diacritics_from_name(user, should_remove_diacritics);

        const names = full_name.toLowerCase().split(" ");

        return names.some((name) => name.startsWith(termlet));
    };
}

export function build_person_matcher(query: string): (user: User) => boolean {
    query = query.trim();

    const termlets = query.toLowerCase().split(/\s+/);
    const termlet_matchers = termlets.map((termlet) => build_termlet_matcher(termlet));

    return function (user: User): boolean {
        const email = user.email.toLowerCase();

        if (email.startsWith(query)) {
            return true;
        }

        return termlet_matchers.every((matcher) => matcher(user));
    };
}

export function filter_people_by_search_terms(users: User[], search_string: string): Set<number> {
    let search_terms = search_string.toLowerCase().split(/[,|]+/);
    search_terms = search_terms.map((s) => s.trim());

    const filtered_users = new Set<number>();

    // Build our matchers outside the loop to avoid some
    // search overhead that is not user-specific.
    const matchers = search_terms.map((search_term) => build_person_matcher(search_term));

    // Loop through users and populate filtered_users only
    // if they include search_terms
    for (const user of users) {
        // Return user emails that include search terms
        const match = matchers.some((matcher) => matcher(user));

        if (match) {
            filtered_users.add(user.user_id);
        }
    }

    return filtered_users;
}

export function dm_matches_search_string(users: User[], search_string: string): boolean {
    const matcher = build_person_matcher(search_string);

    return users.some((user) => matcher(user));
}

export const is_valid_full_name_and_user_id = (full_name: string, user_id: number): boolean => {
    /*
        This function is currently only used for checking
        the mention syntax during markdown parsing. Since
        we do not want to parse inaccessible users as
        mention pill, this function returns false for
        inaccessible users. We would need to update this
        if we would want to use this function for other
        cases where we might want to display inaccessible
        users as "Unknown user".
    */
    const person = people_by_user_id_dict.get(user_id);

    if (!person || person.is_inaccessible_user) {
        return false;
    }

    return person.full_name === full_name;
};

export const get_actual_name_from_user_id = (user_id: number): string | undefined => {
    /*
        If you are dealing with user-entered data, you
        should validate the user_id BEFORE calling
        this function.
    */
    const person = people_by_user_id_dict.get(user_id);

    if (!person) {
        blueslip.error("Unknown user_id", {user_id});
        return undefined;
    }

    return person.full_name;
};

export function get_user_id_from_name(full_name: string): number | undefined {
    // get_user_id_from_name('Alice Smith') === 42

    /*
        This function is intended to be called
        with a full name that is user-entered, such
        a full name from a user mention.

        We will only return a **unique** user_id
        here.  For duplicate names, our UI should
        force users to disambiguate names with a
        user_id and then call is_valid_full_name_and_user_id
        to make sure the combo is valid.  This is
        exactly what we do with mentions.
    */

    /*
        Since we do not want to parse inaccessible users as
        mention pill, this function returns false for
        inaccessible users. We would need to update this if
        we would want to use this function for other cases
        where we might want to display inaccessible users
        as "Unknown user".
    */

    const person = people_by_name_dict.get(full_name);

    if (!person || person.is_inaccessible_user) {
        return undefined;
    }

    if (is_duplicate_full_name(full_name)) {
        return undefined;
    }

    return person.user_id;
}

export function track_duplicate_full_name(
    full_name: string,
    user_id: number,
    to_remove?: boolean,
): void {
    let ids: Set<number>;
    if (duplicate_full_name_data.has(full_name)) {
        // TODO: Better handling for optional values w/o the assertion.
        ids = duplicate_full_name_data.get(full_name)!;
    } else {
        ids = new Set();
    }
    if (!to_remove && user_id) {
        ids.add(user_id);
    }
    if (to_remove && user_id) {
        ids.delete(user_id);
    }
    duplicate_full_name_data.set(full_name, ids);
}

export function is_duplicate_full_name(full_name: string): boolean {
    const ids = duplicate_full_name_data.get(full_name);

    return ids !== undefined && ids.size > 1;
}

export function get_from_unique_full_name(query: string): User | undefined {
    // Check for `full_name|user_id` syntax and return `user_id`.
    const parts = query.split("|");
    if (parts.length !== 2) {
        return undefined;
    }
    const user_id = Number(parts[1]?.trim());
    if (!Number.isNaN(user_id) && is_valid_user_id(user_id)) {
        return get_by_user_id(user_id);
    }
    return undefined;
}

export function get_unique_full_name(full_name: string, user_id: number): string {
    let unique_full_name = full_name;
    if (is_duplicate_full_name(full_name)) {
        unique_full_name += `|${user_id}`;
    }
    return unique_full_name;
}

export function get_mention_syntax(full_name: string, user_id?: number, silent = false): string {
    let mention = "";
    if (silent) {
        mention += "@_**";
    } else {
        mention += "@**";
    }
    const wildcard_match = full_name_matches_wildcard_mention(full_name);
    // TODO: Eventually remove "stream" wildcard from typeahead suggestions
    // once the rename of stream to channel has settled for users.
    // Until then, when selected, replace with "channel" wildcard.
    if (wildcard_match && user_id === undefined && full_name === "stream") {
        mention += "channel";
    } else {
        mention += full_name;
    }

    if (user_id === undefined && !wildcard_match) {
        blueslip.warn("get_mention_syntax called without user_id.");
    }
    if ((is_duplicate_full_name(full_name) || wildcard_match) && user_id !== undefined) {
        mention += `|${user_id}`;
    }
    mention += "**";
    return mention;
}

function full_name_matches_wildcard_mention(full_name: string): boolean {
    return ["all", "everyone", "stream", "channel", "topic"].includes(full_name);
}

export function _add_user(person: User): void {
    /*
        This is common code to add any user, even
        users who may be deactivated or outside
        our realm (like cross-realm bots).
    */
    person.is_moderator = false;
    if (
        [
            settings_config.user_role_values.moderator.code,
            settings_config.user_role_values.admin.code,
            settings_config.user_role_values.owner.code,
        ].includes(person.role)
    ) {
        person.is_moderator = true;
    }
    if (person.user_id) {
        people_by_user_id_dict.set(person.user_id, person);
    } else {
        // We eventually want to lock this down completely
        // and report an error and not update other the data
        // structures here, but we have a lot of edge cases
        // with cross-realm bots, deactivated users, etc.,
        // where we are probably fine for now not to
        // find them via user_id lookups.
        blueslip.warn("No user_id provided", {email: person.email});
    }

    track_duplicate_full_name(person.full_name, person.user_id);
    people_dict.set(person.email, person);
    people_by_name_dict.set(person.full_name, person);
}

export function add_active_user(person: User, source = "initial_fetch"): void {
    // To maintain the valid_user_ids data structure, we must add new
    // users to that set when we learn about them.
    if (source === "server_events") {
        add_valid_user_id(person.user_id);
    }

    active_user_dict.set(person.user_id, person);
    _add_user(person);
    non_active_user_dict.delete(person.user_id);
}

export const is_person_active = (
    user_id: number,
    allow_missing_user: boolean | undefined = undefined,
): boolean => {
    if (!people_by_user_id_dict.has(user_id)) {
        // settings_data.user_can_access_all_other_users can be
        // cheap, so we avoid computing it unless it's actually
        // required.
        allow_missing_user ??= !settings_data.user_can_access_all_other_users();

        if (allow_missing_user) {
            // We consider all inaccessible users as active.
            return true;
        }
        blueslip.error("No user found", {user_id});
    }

    if (cross_realm_dict.has(user_id)) {
        return true;
    }

    return active_user_dict.has(user_id);
};

export function add_cross_realm_user(person: User): void {
    if (!people_dict.has(person.email)) {
        _add_user(person);
    }
    cross_realm_dict.set(person.user_id, person);
    if (person.full_name === "Welcome Bot") {
        WELCOME_BOT = person;
    } else if (person.full_name === "Email Gateway") {
        EMAIL_GATEWAY_BOT = person;
    }
}

export function deactivate(person: User): void {
    // We don't fully remove a person from all of our data
    // structures, because deactivated users can be part
    // of somebody's direct message list.
    active_user_dict.delete(person.user_id);
    non_active_user_dict.set(person.user_id, person);
}

export function remove_inaccessible_user(user_id: number): void {
    // We do not track inaccessible users in active_user_dict.
    active_user_dict.delete(user_id);

    // Create unknown user object for the inaccessible user.
    const email = "user" + user_id + "@" + realm.realm_bot_domain;
    const unknown_user = make_user(user_id, email, INACCESSIBLE_USER_NAME);
    _add_user(unknown_user);
}

export function report_late_add(user_id: number, email: string): void {
    // If the events system is not running, then it is expected that
    // we will fetch messages from the server that were sent by users
    // who don't exist in our users data set. This can happen because
    // we're in the middle of a reload (and thus stopped our event
    // queue polling) or because we are a spectator and never had an
    // event queue in the first place.
    if (reload_state.is_in_progress() || page_params.is_spectator) {
        blueslip.log("Added user late", {user_id, email});
    } else if (!settings_data.user_can_access_all_other_users()) {
        blueslip.log("Message was sent by an inaccessible user", {user_id});
    } else {
        blueslip.error("Added user late", {user_id, email});
    }
}

export function make_user(user_id: number, email: string, full_name: string): User {
    // Used to create fake user objects for users who we see via some
    // API call, such as fetching a message sent by the user, before
    // we receive a full user object for the user via the events
    // system.
    //
    // This function is an ugly hack in that it makes up a lot of
    // values, but usually thesefake user objects only persist for
    // less than a second before being replaced by a real user when
    // the events system receives the user-created event for the new
    // or newly visible user.
    return {
        user_id,
        email,
        full_name,
        role: settings_config.user_role_values.member.code,
        is_active: true,
        is_admin: false,
        is_owner: false,
        is_guest: false,
        is_bot: false,
        is_moderator: false,
        // We explicitly don't set `avatar_url` for fake person objects so that fallback code
        // will ask the server or compute a gravatar URL only once we need the avatar URL,
        // it's important for performance that we not hash every user's email to get gravatar URLs.
        avatar_version: 0,
        timezone: "",
        date_joined: "",
        delivery_email: null,
        profile_data: {},
        bot_type: null,
        // This may lead to cases where this field is set to
        // true for an accessible user also and such user would
        // not be shown in the right sidebar for some time till
        // the user's correct data is received from the server.
        is_inaccessible_user: !settings_data.user_can_access_all_other_users(),

        // This property allows us to distinguish actual server person
        // objects from fake person objects generated by this function.
        is_missing_server_data: true,
    };
}

export function add_inaccessible_user(user_id: number): User {
    const email = "user" + user_id + "@" + realm.realm_bot_domain;
    const unknown_user = make_user(user_id, email, INACCESSIBLE_USER_NAME);
    _add_user(unknown_user);
    return unknown_user;
}

export function get_user_by_id_assert_valid(
    user_id: number,
    allow_missing_user = !settings_data.user_can_access_all_other_users(),
): User {
    if (!allow_missing_user) {
        return get_by_user_id(user_id);
    }

    return maybe_get_user_by_id(user_id, true) ?? add_inaccessible_user(user_id);
}

function get_involved_people(message: MessageWithBooleans): DisplayRecipientUser[] {
    let involved_people: DisplayRecipientUser[] = [];

    if (message.type === "stream") {
        involved_people = [
            {
                full_name: message.sender_full_name,
                id: message.sender_id,
                email: message.sender_email,
            },
        ];
    } else if (message.type === "private") {
        assert(
            typeof message.display_recipient !== "string",
            "Private messages should have list of recipients",
        );
        assert(message.display_recipient !== undefined);
        involved_people = message.display_recipient;
    }

    return involved_people;
}

export function extract_people_from_message(message: MessageWithBooleans): void {
    const involved_people = get_involved_people(message);

    // Add new people involved in this message to the people list
    for (const person of involved_people) {
        const user_id = person.id;

        if (people_by_user_id_dict.has(user_id)) {
            continue;
        }

        report_late_add(user_id, person.email);

        _add_user(make_user(user_id, person.email, person.full_name));
    }
}

function safe_lower(s?: string | null): string {
    return (s ?? "").toLowerCase();
}

export function matches_user_settings_search(person: User, value: string): boolean {
    const email = person.delivery_email;

    return safe_lower(person.full_name).includes(value) || safe_lower(email).includes(value);
}

function matches_user_settings_role(person: User, role_code: number): boolean {
    if (role_code === 0 || role_code === person.role) {
        return true;
    }
    return false;
}

type SettingsUsersFilterQuery = {
    text_search: string;
    role_code: number;
};

export function predicate_for_user_settings_filters(
    person: User,
    query: SettingsUsersFilterQuery,
): boolean {
    /*
        TODO: For text_search:
              For large realms, we can optimize this a couple
              different ways.  For realms that don't show
              emails, we can make a simpler filter predicate
              that works solely with full names.  And we can
              also consider two-pass filters that try more
              stingy criteria first, such as exact prefix
              matches, before widening the search.

              See #13554 for more context.
    */
    return (
        matches_user_settings_search(person, query.text_search) &&
        matches_user_settings_role(person, query.role_code)
    );
}

export function maybe_incr_recipient_count(
    message: MessageWithBooleans & {sent_by_me: boolean},
): void {
    if (message.type !== "private") {
        return;
    }

    assert(
        typeof message.display_recipient !== "string",
        "Private messages should have list of recipients",
    );

    if (!message.sent_by_me) {
        return;
    }

    assert(message.display_recipient !== undefined);

    // Track the number of direct messages we've sent to this person
    // to improve autocomplete
    for (const recip of message.display_recipient) {
        const user_id = recip.id;
        incr_recipient_count(user_id);
    }
}

export function set_full_name(person_obj: User, new_full_name: string): void {
    if (people_by_name_dict.has(person_obj.full_name)) {
        people_by_name_dict.delete(person_obj.full_name);
    }
    // Remove previous and add new full name to the duplicate full name tracker.
    track_duplicate_full_name(person_obj.full_name, person_obj.user_id, true);
    track_duplicate_full_name(new_full_name, person_obj.user_id);
    people_by_name_dict.set(new_full_name, person_obj);
    person_obj.full_name = new_full_name;
    person_obj.name_with_diacritics_removed = undefined;
}

export function set_custom_profile_field_data(
    user_id: number,
    field: {id: number; value: string | null; rendered_value?: string | undefined},
): void {
    if (field.id === undefined) {
        blueslip.error("Trying to set undefined field id");
        return;
    }
    const person = get_by_user_id(user_id);
    assert(person.profile_data !== undefined);
    if (field.value === null) {
        // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
        delete person.profile_data[field.id];
    } else {
        person.profile_data[field.id] = {
            value: field.value,
            rendered_value: field.rendered_value,
        };
    }
}

export function initialize_current_user(user_id: number): void {
    my_user_id = user_id;
}

export function my_full_name(): string {
    const person = get_by_user_id(my_user_id);
    return person.full_name;
}

export function my_current_email(): string {
    const person = get_by_user_id(my_user_id);
    return person.email;
}

export function my_current_user_id(): number {
    return my_user_id;
}

export function my_custom_profile_data(field_id: number): ProfileDatum | undefined {
    if (field_id === undefined) {
        blueslip.error("Undefined field id");
        return undefined;
    }
    return get_custom_profile_data(my_user_id, field_id);
}

export function get_custom_profile_data(
    user_id: number,
    field_id: number,
): ProfileDatum | undefined {
    const person = get_by_user_id(user_id);
    return person.profile_data?.[field_id];
}

export function get_custom_fields_by_type(
    user_id: number,
    field_type: number,
): (ProfileDatum | undefined)[] | undefined {
    const person = get_by_user_id(user_id);
    const profile_data = person.profile_data;
    if (profile_data === undefined) {
        return undefined;
    }
    const filteredProfileData: (ProfileDatum | undefined)[] = [];
    for (const field of realm.custom_profile_fields) {
        if (field.type === field_type) {
            filteredProfileData.push(profile_data[field.id]);
        }
    }
    return filteredProfileData;
}

export function is_my_user_id(user_id: number): boolean {
    return user_id === my_user_id;
}

export function is_direct_message_conversation_with_self(user_ids: number[]): boolean {
    if (user_ids.length === 1) {
        return is_my_user_id(user_ids[0]!);
    }
    return false;
}

export function compare_by_name(a: User, b: User): number {
    return util.strcmp(a.full_name, b.full_name);
}

export function sort_but_pin_current_user_on_top(users: User[]): void {
    const my_user = get_by_user_id(my_user_id);
    if (users.includes(my_user)) {
        users.splice(users.indexOf(my_user), 1);
        users.sort(compare_by_name);
        users.unshift(my_user);
    } else {
        users.sort(compare_by_name);
    }
}

export function is_displayable_conversation_participant(user_id: number): boolean {
    return !is_valid_bot_user(user_id) && is_person_active(user_id);
}

export function populate_valid_user_ids(params: StateData["user_groups"]): void {
    // Every valid user ID is guaranteed to exist in at least one
    // system group, so we can us that to compute the set of valid
    // user IDs in the realm.
    for (const user_group of params.realm_user_groups) {
        if (user_group.is_system_group) {
            valid_user_ids = valid_user_ids.union(new Set(user_group.members));
        }
    }
}

function get_combined_promise_for_user_ids(user_ids: Set<number>): {
    promise_for_all_requested_users: Promise<unknown>;
    user_ids_pending_fetch: Set<number>;
} {
    // Remove users which are already fetched.
    // Since start_fetch_for_requested_users is called after a `setTimeout`,
    // it is possible that some users have already been fetched.
    for (const user_id of user_ids) {
        if (people_by_user_id_dict.has(user_id)) {
            /* istanbul ignore next */
            user_ids.delete(user_id);
        }
    }

    const promises: Promise<void>[] = [];
    let user_ids_pending_fetch = new Set<number>(user_ids);
    // Check if we have an ongoing fetch that includes some of the
    // users needed by this request.
    for (const [user_ids_set, promise_data] of fetch_users_storage.promise_for_in_transit) {
        if (user_ids_set.intersection(user_ids_pending_fetch).size > 0) {
            user_ids_pending_fetch = user_ids_pending_fetch.difference(user_ids_set);
            promises.push(promise_data.promise);
        }
    }

    if (user_ids_pending_fetch.size > 0) {
        // Store a promise to be resolved when user_ids_pending_fetch is fetched.
        // This avoids future requests for subset of `user_ids_pending_fetch` to wait
        // for the completion of `user_ids_to_fetch`.
        let resolver_for_promise_for_pending_fetch: () => void = () => {
            // This will reassigned instantly below but Typescript thinks
            // this function is unassigned.
        };
        const promise_for_pending_fetch = new Promise<void>((resolve) => {
            resolver_for_promise_for_pending_fetch = resolve;
        });
        promises.push(promise_for_pending_fetch);

        fetch_users_storage.promise_for_in_transit.set(user_ids_pending_fetch, {
            promise: promise_for_pending_fetch,
            resolver: resolver_for_promise_for_pending_fetch,
        });
    }

    return {
        promise_for_all_requested_users: Promise.all(promises),
        user_ids_pending_fetch,
    };
}

function start_fetch_for_requested_users(): void {
    const user_ids_to_fetch = fetch_users_storage.pending_user_ids;
    fetch_users_storage.pending_user_ids = new Set();
    fetch_users_storage.in_transit_user_ids =
        fetch_users_storage.in_transit_user_ids.union(user_ids_to_fetch);

    const {promise_for_all_requested_users, user_ids_pending_fetch} =
        get_combined_promise_for_user_ids(user_ids_to_fetch);

    // This promise will be resolved when all users are fetched.
    fetch_users_storage.promise_for_requested.set(user_ids_to_fetch, {
        promise: fetch_users_storage.promise_for_pending!,
        resolver: fetch_users_storage.promise_resolver_for_pending!,
    });

    fetch_users_storage.promise_for_pending = undefined;
    fetch_users_storage.promise_resolver_for_pending = undefined;

    const fetch_request_with_retry = (num_attempts = 1): void => {
        void fetch_users(user_ids_pending_fetch).then(
            async (fetched_users) => {
                for (const user of fetched_users) {
                    if (user.is_active) {
                        add_active_user(user);
                    } else {
                        non_active_user_dict.set(user.user_id, user);
                        _add_user(user);
                    }
                }

                // Resolve promises waiting on this fetch after updating the data locally.
                fetch_users_storage.promise_for_in_transit.get(user_ids_pending_fetch)!.resolver();
                // Clean up in transit promise for this fetch.
                fetch_users_storage.promise_for_in_transit.delete(user_ids_pending_fetch);
                // Remove fetched users from in transit user ids.
                fetch_users_storage.in_transit_user_ids =
                    fetch_users_storage.in_transit_user_ids.difference(user_ids_pending_fetch);

                await promise_for_all_requested_users;
                // Resolve promises waiting on the complete fetch.
                fetch_users_storage.promise_for_requested.get(user_ids_to_fetch)!.resolver();
                fetch_users_storage.promise_for_requested.delete(user_ids_pending_fetch);
            },
            (error: unknown) => {
                // Retry on error.
                num_attempts += 1;
                const retry_delay_secs = util.get_retry_backoff_seconds(undefined, num_attempts);

                // Since users are in `valid_user_ids`, we expect
                // the fetch to eventually succeed, so we log a warning
                // and retry after a delay.
                blueslip.warn(
                    `Fetch for users failed, retrying after ${Math.round(retry_delay_secs)} seconds. ` +
                        String(error),
                );
                setTimeout(() => {
                    fetch_request_with_retry(num_attempts);
                }, retry_delay_secs * 1000);
            },
        );
    };

    fetch_request_with_retry();
}

export let fetch_users_from_ids_internal = async (user_ids: number[]): Promise<unknown> => {
    // NOTE: NEVER USE THIS FUNCTION DIRECTLY.
    // Call get_or_fetch_users_from_ids instead.
    const unknown_user_ids = new Set(
        user_ids.filter((user_id) => !people_by_user_id_dict.has(user_id)),
    );

    // We already have data for all requested users.
    if (unknown_user_ids.size === 0) {
        return undefined;
    }

    // If the fetches in progress contain all of the unknown user IDs,
    // return a promise that resolves when that fetch completes.
    if (
        unknown_user_ids.intersection(fetch_users_storage.in_transit_user_ids).size ===
        unknown_user_ids.size
    ) {
        // Await for all the fetches in progress for the unknown user IDs.
        const {promise_for_all_requested_users, user_ids_pending_fetch} =
            get_combined_promise_for_user_ids(unknown_user_ids);
        assert(user_ids_pending_fetch.size === 0);
        return promise_for_all_requested_users;
    }

    // Add users to be fetched in the next fetch attempt.
    fetch_users_storage.pending_user_ids =
        fetch_users_storage.pending_user_ids.union(unknown_user_ids);

    // Return promise for pending fetch if it exists.
    if (fetch_users_storage.promise_for_pending !== undefined) {
        return fetch_users_storage.promise_for_pending;
    }

    // Create promise for a next fetch attempt.
    const promise = new Promise<void>((resolve) => {
        fetch_users_storage.promise_resolver_for_pending = resolve;
    });
    fetch_users_storage.promise_for_pending = promise;
    // To club multiple fetch requests together,
    // we queue the fetch after current call stack.
    setTimeout(() => {
        if (fetch_users_storage.pending_user_ids.size > 0) {
            start_fetch_for_requested_users();
        }
    }, 0);
    return promise;
};

export function rewire_fetch_users_from_ids_internal(
    value: typeof fetch_users_from_ids_internal,
): void {
    fetch_users_from_ids_internal = value;
}

export async function get_or_fetch_users_from_ids(user_ids: number[]): Promise<User[]> {
    await fetch_users_from_ids_internal(user_ids);
    // In case `valid_user_ids` got updated while we were fetching,
    // re-filter the user_ids to only return valid ones.
    const user_ids_valid = valid_user_ids.intersection(new Set(user_ids));
    // Server doesn't return data for inaccessible users, so we need to
    // make fake user objects for them if needed.
    const ignore_missing = !settings_data.user_can_access_all_other_users();
    const users: User[] = [];
    for (const user_id of user_ids_valid) {
        const person = maybe_get_user_by_id(user_id, ignore_missing);
        if (person) {
            users.push(person);
        } else {
            // maybe_get_user_by_id will throw an error if ignore_missing is false.
            // User is inaccessible, create a fake user object.
            users.push(add_inaccessible_user(user_id));
        }
    }
    return users;
}

export function fetch_users_from_server(opts: FetchUserDataParams): void {
    const params = {
        user_ids: opts.user_ids,
        client_gravatar: opts.client_gravatar,
        include_custom_profile_fields: opts.include_custom_profile_fields,
    };

    channel.get({
        url: "/json/users",
        data: params,
        success(data) {
            if (opts.success) {
                const members = user_fetch_response_schema.parse(data).members;
                opts.success(members);
            }
        },
        error(xhr: JQuery.jqXHR<unknown>) {
            if (opts.error) {
                opts.error(xhr);
            }
        },
    });
}
export function get_users_that_match_role_ids(
    user_ids: Set<number>,
    role_ids: Set<number>,
): User[] {
    const users = new Array<User>();
    for (const user_id of user_ids) {
        const person = get_by_user_id(user_id);
        if (person && role_ids.has(person.role)) {
            users.push(person);
        }
    }
    return users;
}

export async function fetch_users(user_ids: Set<number>): Promise<UsersFetchResponse["members"]> {
    // Requested users outside the set of known valid user IDs likely
    // reflect some sort of Zulip bug, so fetch and log them.
    const invalid_user_ids = user_ids.difference(valid_user_ids);
    if (invalid_user_ids.size > 0) {
        blueslip.error("Ignored invalid user_ids: " + [...invalid_user_ids].join(", "));
    }

    const user_ids_to_fetch = valid_user_ids.intersection(user_ids);
    if (user_ids_to_fetch.size === 0) {
        return [];
    }
    return new Promise((resolve, reject) => {
        fetch_users_from_server({
            // POST /register obtains custom profile field data if and only if
            // the current user is not a spectator. Mimic this behavior.
            include_custom_profile_fields: !page_params.is_spectator,
            user_ids: JSON.stringify([...user_ids_to_fetch]),
            success(users) {
                resolve(users);
            },
            error(xhr) {
                let error_message = "Failed to fetch users.";
                if (xhr) {
                    const error = z
                        .object({msg: z.optional(z.string())})
                        .safeParse(xhr.responseJSON);
                    if (error.success && error.data.msg) {
                        error_message = error.data.msg;
                    }
                }
                reject(new Error(error_message));
            },
        });
    });
}

export async function initialize(
    my_user_id: number,
    people_params: StateData["people"],
    user_group_params: StateData["user_groups"],
): Promise<void> {
    initialize_current_user(my_user_id);
    populate_valid_user_ids(user_group_params);

    // Compute the set of user IDs that we know are valid in the
    // organization, but do not have a copy of.
    const user_ids_to_fetch = new Set(valid_user_ids);
    for (const person of people_params.realm_users) {
        add_active_user(person);
        user_ids_to_fetch.delete(person.user_id);
    }

    for (const person of people_params.realm_non_active_users) {
        non_active_user_dict.set(person.user_id, person);
        _add_user(person);
        user_ids_to_fetch.delete(person.user_id);
    }

    for (const person of people_params.cross_realm_bots) {
        add_cross_realm_user(person);
        user_ids_to_fetch.delete(person.user_id);
    }

    // Fetch all the missing users. This code path is temporary: We
    // plan to move to a model where the web app expects to have an
    // incomplete users dataset in large organizations.
    await get_or_fetch_users_from_ids([...user_ids_to_fetch]);
}
